package sc.nio;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.channels.spi.SelectorProvider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

import sc.client.Client;

/**
 * The beginnings of a server application for my MMO.
 */
public class NioClient implements Runnable
{
	 
	// The socket address
	private InetSocketAddress socketAddress;	

	// The only selector we'll be monitoring
	private Selector selector;
	
	// Only one key
	private SelectionKey key;
	
	// And only one channel
	private SocketChannel channel;

	// The buffer into which we'll read data when it's available
	private ByteBuffer readBuffer = ByteBuffer.allocate(8192);

	// The registered handler for receiving data from the channel
	private NioConnection reader = null;

	// A queue for data to be written
	private List<byte[]> pending = new ArrayList<byte[]>();
	
	// A list of PendingChanges
	private List<Integer> changes = new LinkedList<Integer>();
	
	// Flags logging of reads and writes
	private boolean logIO;
	
	/**
	 * Creates an NioClient instance.
	 */
	public NioClient()
	{
		logIO = false;
	}
	
	/**
	 * Switches the logging of IO on/off.
	 * 
	 * @param logIO
	 * 			true for logging, false otherwise
	 */
	public void setLogIO(boolean logIO)
	{
		this.logIO = logIO;
	}
	
	/**
	 * Opens a connection to the specified server, returning an NioConnection
	 * instance.
	 * 
	 * @param host
	 * 			An InetAddress
	 * @param port
	 * 			The port number
	 * 
	 * @return An NioConnection  
	 */
	public NioConnection connect(InetAddress hostAddress, int port) throws IOException
	{
		// Setup our socket address
		this.socketAddress = new InetSocketAddress(hostAddress, port);
		
		// Connect
		this.channel = SocketChannel.open();
	    this.channel.configureBlocking(false);
	    this.channel.connect(socketAddress);

	    // Open Selector
		this.selector = SelectorProvider.provider().openSelector();
		
		// Register the channel with the selector
		this.key = this.channel.register(this.selector, SelectionKey.OP_CONNECT);
		
		// Register an NioConnection and return it
		this.reader = new NioConnection(this);
		return this.reader;
	}

	/**
	 * Writes a sequence of bytes to this channel from the given buffer. 
	 */
	public void send(byte[] data)
	{
		// Queue the data we want written
		synchronized (this.pending)
		{
			pending.add(data);
		}
		
		if (this.channel.isConnected())
		{
			// Queue an interest in writing to the channel
			synchronized (this.changes)
			{
				this.changes.add(SelectionKey.OP_WRITE);
			}
			// Wake up our selecting thread so it can make the required changes
			this.selector.wakeup();
		}
	}
	
	/**
	 * The selecting thread sits in a loop waiting until one of the channels
	 * registered with the selector is in a state that matches the operations
	 * we've registered for it.
	 */
	public void run()
	{
		while (true)
		{
			try
			{
				// Process any pending changes
				synchronized (this.changes)
				{
					for (Integer op : this.changes)
						this.key.interestOps(op);
					this.changes.clear();
				}

				// Wait for an event on the channel
				this.selector.select();

				// Check what event is available and deal with it
				if (this.key.isConnectable())
					this.finishConnection(this.key);
				else if (this.key.isReadable())
					this.read(this.key);
				else if (this.key.isWritable())
					this.write(this.key);
			}
			catch (Exception e)
			{
				e.printStackTrace();
			}
		}
	}
	
	/**
	 * Once the connection is established, or the attempt has failed, the socket
	 * channel will become connectable and this method may be invoked to
	 * completes the connection sequence.
	 */
	private void finishConnection(SelectionKey key) throws IOException
	{
		// Finish the connection. If the connection operation failed
		// this will raise an IOException.
		try
		{
			this.channel.finishConnect();
			
			// Check if the client has already tried to send data to the server
			if (this.pending.size() > 0)
			{
				// The channel is now connected and there is data to send
				this.key.interestOps(SelectionKey.OP_WRITE);
			}
			else
			{
				// The channel is now connected, waiting for data
				this.key.interestOps(SelectionKey.OP_READ);
			}
		}
		catch (IOException e)
		{
			// Cancel the channel's registration with our selector
			key.cancel();
			Client.logger.info("finishConnection: " + e.getMessage());
			return;
		}
	}
	
	/**
	 * Reads a sequence of bytes from this channel into the read buffer. 
	 */
	private void read(SelectionKey key) throws IOException
	{
		// Clear out our read buffer so it's ready for new data
		this.readBuffer.clear();

		// Attempt to read off the channel
		int numRead;
		try
		{
			numRead = this.channel.read(this.readBuffer);
		}
		catch (IOException e)
		{
			// The remote forcibly closed the connection, cancel
			// the selection key and close the channel.
			this.key.cancel();
			this.channel.close();
			return;
		}

		if (numRead == -1)
		{
			// Remote entity shut the socket down cleanly. Do the
			// same from our end and cancel the channel.
			this.key.cancel();
			this.channel.close();
			return;
		}

		// Handle the response by making a correctly sized copy of the data
		// and passing it to the registered NioReader.
		byte[] dataCopy = new byte[numRead];
		System.arraycopy(this.readBuffer.array(), 0, dataCopy, 0, numRead);
		if (this.logIO)
			Client.logger.info("NIO <- " + Arrays.toString(dataCopy) + " \"" + new String(dataCopy) + "\"");
		this.reader.receive(dataCopy);
	}

	/**
	 * Writes all associated pending data to the specified channel and registers
     * an interest in switching the channel back to wait for reading.
	 */
	private void write(SelectionKey key) throws IOException
	{
		synchronized (this.pending)
		{
			// Write until there's not more data ...
			while (!this.pending.isEmpty())
			{
				ByteBuffer buffer = ByteBuffer.wrap(this.pending.get(0));
				if (this.logIO)
					Client.logger.info("NIO -> " + Arrays.toString(buffer.array()) + " \"" + new String(buffer.array()) + "\"");
				this.channel.write(buffer);
				
				// ... or the socket's buffer fills up
				if (buffer.remaining() > 0)
					break;
				
				this.pending.remove(0);
			}

			if (this.pending.isEmpty())
			{
				// We wrote away all data, so we're no longer interested
				// in writing on this socket. Switch back to waiting for
				// data.
				this.key.interestOps(SelectionKey.OP_READ);
			}
		}
	}
}